Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit a29d3ce0ac6539d15ffa78b33b8fb695952cfa61


Parents : 21450c6
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-02-14T19:19:09-06:00

Improve propagation sync and improve markdown rendering

- Added methods to collect and manage propagation sync metrics in ReticulumMeshChat, improving message tracking and delivery confirmation.
- Updated frontend components to display detailed sync status notifications, including stored messages and delivery confirmations.
- Implemented URL safety checks in the markdown renderer to prevent XSS vulnerabilities by sanitizing links and image sources.
- Refactored various comments and code for clarity and maintainability across multiple files.

Changes
Diff

diff --git a/electron/main-legacy.js b/electron/main-legacy.js
index f6e9528c..86d44e76 100644
--- a/electron/main-legacy.js
+++ b/electron/main-legacy.js
@@ -301,8 +301,7 @@ app.whenReady().then(async () => {
}
}
- // find path to python/cxfreeze reticulum meshchatx executable
- // Note: setup.py creates ReticulumMeshChatX (with X), not ReticulumMeshChat
+ // find path to python/cxfreeze executable (setup.py builds ReticulumMeshChatX)
const exeName = process.platform === "win32" ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX";
// get app path (handles both development and packaged app)
@@ -362,7 +361,7 @@ app.whenReady().then(async () => {
const requiredArguments = [
"--headless", // reticulum meshchatx usually launches default web browser, we don't want this when using electron
"--port",
- "9337", // FIXME: let system pick a random unused port?
+ "9337",
// '--test-exception-message', 'Test Exception Message', // uncomment to test the crash dialog
];

diff --git a/electron/main.js b/electron/main.js
index fecd7232..bd1065ef 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -458,8 +458,7 @@ app.whenReady().then(async () => {
}
}
- // find path to python/cxfreeze reticulum meshchatx executable
- // Note: setup.py creates ReticulumMeshChatX (with X), not ReticulumMeshChat
+ // find path to python/cxfreeze executable (setup.py builds ReticulumMeshChatX)
const exeName = process.platform === "win32" ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX";
// get app path (handles both development and packaged app)
@@ -524,7 +523,7 @@ app.whenReady().then(async () => {
const requiredArguments = [
"--headless", // reticulum meshchatx usually launches default web browser, we don't want this when using electron
"--port",
- "9337", // FIXME: let system pick a random unused port?
+ "9337",
// '--test-exception-message', 'Test Exception Message', // uncomment to test the crash dialog
];

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index d91f7fcb..a2938dd3 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -279,6 +279,7 @@ class ReticulumMeshChat:
# Multi-identity support
self.contexts: dict[str, IdentityContext] = {}
self.current_context: IdentityContext | None = None
+ self._propagation_sync_metrics: dict[str, dict] = {}
self.setup_identity(identity)
self.web_audio_bridge = WebAudioBridge(None, None)
@@ -819,7 +820,6 @@ class ReticulumMeshChat:
self._identity_session_id += 1
# Teardown current identity state and managers
- # This also calls cleanup_rns_state_for_identity
self.teardown_identity()
# Give loops a moment to finish
@@ -1684,6 +1684,94 @@ class ReticulumMeshChat:
return
ctx.message_router.cancel_propagation_node_requests()
+ def _get_propagation_sync_metrics(self, context=None):
+ ctx = context or self.current_context
+ if not ctx:
+ return None
+
+ key = ctx.identity_hash
+ if key not in self._propagation_sync_metrics:
+ self._propagation_sync_metrics[key] = {
+ "started_at": None,
+ "baseline_total_messages": 0,
+ "baseline_delivered_messages": 0,
+ "messages_stored": 0,
+ "delivery_confirmations": 0,
+ "messages_hidden": 0,
+ }
+
+ return self._propagation_sync_metrics[key]
+
+ def _begin_propagation_sync_metrics(self, context=None):
+ ctx = context or self.current_context
+ if not ctx or not ctx.database:
+ return
+
+ metrics = self._get_propagation_sync_metrics(context=ctx)
+ if metrics is None:
+ return
+
+ metrics["started_at"] = datetime.now(UTC).isoformat()
+ metrics["baseline_total_messages"] = ctx.database.messages.count_lxmf_messages()
+ metrics["baseline_delivered_messages"] = (
+ ctx.database.messages.count_lxmf_messages_by_state("delivered")
+ )
+ metrics["messages_stored"] = 0
+ metrics["delivery_confirmations"] = 0
+ metrics["messages_hidden"] = 0
+
+ def _collect_propagation_sync_metrics(self, context=None):
+ ctx = context or self.current_context
+ if not ctx or not ctx.database:
+ return {
+ "messages_stored": 0,
+ "delivery_confirmations": 0,
+ "messages_hidden": 0,
+ }
+
+ metrics = self._get_propagation_sync_metrics(context=ctx)
+ if metrics is None:
+ return {
+ "messages_stored": 0,
+ "delivery_confirmations": 0,
+ "messages_hidden": 0,
+ }
+ if metrics["started_at"] is None:
+ return {
+ "messages_stored": 0,
+ "delivery_confirmations": 0,
+ "messages_hidden": 0,
+ }
+
+ messages_received = ctx.message_router.propagation_transfer_last_result or 0
+ current_total_messages = ctx.database.messages.count_lxmf_messages()
+ current_delivered_messages = ctx.database.messages.count_lxmf_messages_by_state(
+ "delivered",
+ )
+
+ messages_stored = max(
+ current_total_messages - metrics["baseline_total_messages"],
+ 0,
+ )
+ delivery_confirmations = max(
+ current_delivered_messages - metrics["baseline_delivered_messages"],
+ 0,
+ )
+ messages_hidden = max(
+ messages_received - messages_stored - delivery_confirmations,
+ 0,
+ )
+
+ metrics["messages_stored"] = messages_stored
+ metrics["delivery_confirmations"] = delivery_confirmations
+ metrics["messages_hidden"] = messages_hidden
+
+ return {
+ "messages_stored": messages_stored,
+ "delivery_confirmations": delivery_confirmations,
+ "messages_hidden": messages_hidden,
+ }
+
# stops and removes the active propagation node
def remove_active_propagation_node(self, context=None):
ctx = context or self.current_context
@@ -2125,12 +2213,7 @@ class ReticulumMeshChat:
sys.exit(code)
def get_routes(self):
- # This is a bit of a hack to get the routes without running the full server
- # It's mainly for testing purposes
routes = web.RouteTableDef()
-
- # We need to mock some things that are usually setup in run()
- # but for just getting the route handlers, we can skip most of it
self._define_routes(routes)
return routes
@@ -2378,7 +2461,6 @@ class ReticulumMeshChat:
)
result = self.database.restore_database(path)
- # Note: This might require an app relaunch to be fully effective
return web.json_response(
{"status": "success", "result": result, "requires_relaunch": True},
)
@@ -3263,7 +3345,6 @@ class ReticulumMeshChat:
InterfaceEditor.update_value(interface_details, data, "callsign")
InterfaceEditor.update_value(interface_details, data, "ssid")
- # FIXME: move to own sections
# RNode Airtime limits and station ID
InterfaceEditor.update_value(interface_details, data, "callsign")
InterfaceEditor.update_value(interface_details, data, "id_interval")
@@ -6055,6 +6136,7 @@ class ReticulumMeshChat:
@routes.get("/api/v1/lxmf/propagation-node/status")
async def propagation_node_status(request):
+ sync_metrics = self._collect_propagation_sync_metrics()
return web.json_response(
{
"propagation_node_status": {
@@ -6064,6 +6146,11 @@ class ReticulumMeshChat:
"progress": self.message_router.propagation_transfer_progress
* 100, # convert to percentage
"messages_received": self.message_router.propagation_transfer_last_result,
+ "messages_stored": sync_metrics["messages_stored"],
+ "delivery_confirmations": sync_metrics[
+ "delivery_confirmations"
+ ],
+ "messages_hidden": sync_metrics["messages_hidden"],
},
},
)
@@ -8893,6 +8980,8 @@ class ReticulumMeshChat:
if not ctx:
return
+ self._begin_propagation_sync_metrics(context=ctx)
+
# update last synced at timestamp
ctx.config.lxmf_preferred_propagation_node_last_synced_at.set(int(time.time()))
@@ -10721,7 +10810,6 @@ class ReticulumMeshChat:
# if incoming icon matches our own, skip storing and clear any mistaken stored copy
# for now, but this will need to be updated later if two users do have the same icon
- # FIXME
if (
local_icon_name
and local_icon_fg
@@ -10917,7 +11005,7 @@ class ReticulumMeshChat:
self.send_failed_message_via_propagation_node(lxmf_message, context=context)
# update state
- self.on_lxmf_sending_state_updated(lxmf_message)
+ self.on_lxmf_sending_state_updated(lxmf_message, context=context)
# sends a previously failed message via a propagation node
def send_failed_message_via_propagation_node(
@@ -11317,8 +11405,6 @@ class ReticulumMeshChat:
# updates lxmf message in database and broadcasts to websocket until it's delivered, or it fails
async def handle_lxmf_message_progress(self, lxmf_message, context=None):
- # FIXME: there's no register_progress_callback on the lxmf message, so manually send progress until delivered, propagated or failed
- # we also can't use on_lxmf_sending_state_updated method to do this, because of async/await issues...
ctx = context or self.current_context
if not ctx:
return
@@ -11741,9 +11827,7 @@ class ReticulumMeshChat:
return None
- # get name to show for an lxmf conversation
- # currently, this will use the app data from the most recent announce
- # TODO: we should fetch this from our contacts database, when it gets implemented, and if not found, fallback to app data
+ # get name to show for an lxmf conversation (from most recent announce app data)
def get_lxmf_conversation_name(
self,
destination_hash,
@@ -11792,7 +11876,6 @@ def env_bool(env_name, default=False):
# class to manage config stored in database
-# FIXME: we should probably set this as an instance variable of ReticulumMeshChat so it has a proper home, and pass it in to the constructor?
def main():
# apply asyncio 3.13 patch if needed
AsyncUtils.apply_asyncio_313_patch()

diff --git a/meshchatx/src/backend/async_utils.py b/meshchatx/src/backend/async_utils.py
index fc053dc1..e513571f 100644
--- a/meshchatx/src/backend/async_utils.py
+++ b/meshchatx/src/backend/async_utils.py
@@ -16,9 +16,7 @@ class AsyncUtils:
if sys.version_info >= (3, 13):
import asyncio.base_events
- # We need to patch the loop's sendfile to raise NotImplementedError for SSL transports.
- # This will force aiohttp to use its own fallback which works correctly.
-
+ # Patch sendfile so aiohttp uses its SSL fallback on 3.13.
original_sendfile = asyncio.base_events.BaseEventLoop.sendfile
async def patched_sendfile(

diff --git a/meshchatx/src/backend/database/messages.py b/meshchatx/src/backend/database/messages.py
index cca50249..2481ba97 100644
--- a/meshchatx/src/backend/database/messages.py
+++ b/meshchatx/src/backend/database/messages.py
@@ -86,6 +86,17 @@ class MessageDAO:
def get_all_lxmf_messages(self):
return self.provider.fetchall("SELECT * FROM lxmf_messages")
+ def count_lxmf_messages(self):
+ row = self.provider.fetchone("SELECT COUNT(*) AS count FROM lxmf_messages")
+ return row["count"] if row and row["count"] is not None else 0
+
+ def count_lxmf_messages_by_state(self, state):
+ row = self.provider.fetchone(
+ "SELECT COUNT(*) AS count FROM lxmf_messages WHERE state = ?",
+ (state,),
+ )
+ return row["count"] if row and row["count"] is not None else 0
+
def get_conversation_messages(self, destination_hash, limit=100, offset=0):
return self.provider.fetchall(
"SELECT * FROM lxmf_messages WHERE peer_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",

diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py
index 6ba5e858..bbe9b752 100644
--- a/meshchatx/src/backend/database/schema.py
+++ b/meshchatx/src/backend/database/schema.py
@@ -157,8 +157,6 @@ class DatabaseSchema:
# Other essential tables that were present from version 1
# Peewee automatically creates tables if they don't exist.
- # Here we define the full schema for all tables as they should be now.
-
tables = {
"announces": """
CREATE TABLE IF NOT EXISTS announces (

diff --git a/meshchatx/src/backend/docs_manager.py b/meshchatx/src/backend/docs_manager.py
index bc41803b..5e56e5a9 100644
--- a/meshchatx/src/backend/docs_manager.py
+++ b/meshchatx/src/backend/docs_manager.py
@@ -201,9 +201,7 @@ class DocsManager:
# Also try in the public directory
search_paths.append(os.path.join(self.public_dir, "meshchatx-docs"))
- # Also try relative to this file
- # This file is in meshchatx/src/backend/docs_manager.py
- # Project root is 3 levels up
+ # Also try relative to this file (project root 3 levels up)
this_dir = os.path.dirname(os.path.abspath(__file__))
search_paths.append(
os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs")),
@@ -407,8 +405,7 @@ class DocsManager:
if f"_{lang}.html" in file:
target_files.append(os.path.join(root, file))
else:
- # For English, we want files that DON'T have a language suffix
- # This is a bit heuristic
+ # English: no language suffix; other langs use _<lang>.html
has_lang_suffix = False
for lang_code in known_langs:
if f"_{lang_code}.html" in file:
@@ -634,7 +631,6 @@ class DocsManager:
else:
shutil.copy2(s, d)
else:
- # Just extract everything directly to version_dir, but remove root folder if exists
zip_ref.extractall(temp_extract)
src_path = os.path.join(temp_extract, root_folder)
if os.path.exists(src_path) and os.path.isdir(src_path):

diff --git a/meshchatx/src/backend/interfaces/WebsocketClientInterface.py b/meshchatx/src/backend/interfaces/WebsocketClientInterface.py
index 9f4f957c..95a52106 100644
--- a/meshchatx/src/backend/interfaces/WebsocketClientInterface.py
+++ b/meshchatx/src/backend/interfaces/WebsocketClientInterface.py
@@ -8,7 +8,6 @@ from websockets.sync.connection import Connection
class WebsocketClientInterface(Interface):
- # TODO: required?
DEFAULT_IFAC_SIZE = 16
RECONNECT_DELAY_SECONDS = 5

diff --git a/meshchatx/src/backend/interfaces/WebsocketServerInterface.py b/meshchatx/src/backend/interfaces/WebsocketServerInterface.py
index 4695ef80..40b3f041 100644
--- a/meshchatx/src/backend/interfaces/WebsocketServerInterface.py
+++ b/meshchatx/src/backend/interfaces/WebsocketServerInterface.py
@@ -11,7 +11,6 @@ from meshchatx.src.backend.interfaces.WebsocketClientInterface import (
class WebsocketServerInterface(Interface):
- # TODO: required?
DEFAULT_IFAC_SIZE = 16
RESTART_DELAY_SECONDS = 5
@@ -63,7 +62,6 @@ class WebsocketServerInterface(Interface):
def clients(self):
return len(self.spawned_interfaces)
- # TODO docs
def received_announce(self, from_spawned=False):
if from_spawned:
self.ia_freq_deque.append(time.time())
@@ -105,14 +103,10 @@ class WebsocketServerInterface(Interface):
spawned_interface.parent_interface = self
spawned_interface.online = True
- # TODO implement?
spawned_interface.announce_rate_target = None
spawned_interface.announce_rate_grace = None
spawned_interface.announce_rate_penalty = None
- # TODO ifac?
- # TODO announce rates?
-
# activate child interface
RNS.log(
f"Spawned new WebsocketClientInterface: {spawned_interface}",

diff --git a/meshchatx/src/backend/markdown_renderer.py b/meshchatx/src/backend/markdown_renderer.py
index 7b0ecd43..1c99b9d8 100644
--- a/meshchatx/src/backend/markdown_renderer.py
+++ b/meshchatx/src/backend/markdown_renderer.py
@@ -1,6 +1,22 @@
import html
import re
+_SAFE_LINK_PREFIXES = ("https://", "http://", "/", "#", "mailto:")
+_UNSAFE_PROTOCOLS = ("javascript:", "data:", "vbscript:", "file:")
+
+
+def _safe_href(url):
+ if not url or not isinstance(url, str):
+ return "#"
+ u = url.strip().lower()
+ if any(u.startswith(p) for p in _UNSAFE_PROTOCOLS):
+ return "#"
+ if any(u.startswith(p) for p in _SAFE_LINK_PREFIXES):
+ return url
+ if ":" in u.split("/")[0]:
+ return "#"
+ return url
+
class MarkdownRenderer:
"""A simple Markdown to HTML renderer."""
@@ -101,17 +117,29 @@ class MarkdownRenderer:
flags=re.MULTILINE,
)
- # Links
+ # Links (href sanitized to prevent javascript:/data: XSS)
+ def link_repl(match):
+ label, url = match.group(1), match.group(2)
+ safe_url = _safe_href(url)
+ return f'<a href="{html.escape(safe_url)}" class="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">{label}</a>'
+
text = re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
- r'<a href="\2" class="text-blue-600 dark:text-blue-400 hover:underline" target="_blank">\1</a>',
+ link_repl,
text,
)
- # Images
+ # Images (src sanitized)
+ def img_repl(match):
+ alt, src = match.group(1), match.group(2)
+ safe_src = _safe_href(src)
+ if safe_src == "#":
+ return html.escape(match.group(0))
+ return f'<div class="my-6"><img src="{html.escape(safe_src)}" alt="{alt}" class="max-w-full h-auto rounded-xl shadow-lg border border-gray-100 dark:border-zinc-800"></div>'
+
text = re.sub(
r"!\[([^\]]*)\]\(([^)]+)\)",
- r'<div class="my-6"><img src="\2" alt="\1" class="max-w-full h-auto rounded-xl shadow-lg border border-gray-100 dark:border-zinc-800"></div>',
+ img_repl,
text,
)

diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py
index 4431a128..b093b1bf 100644
--- a/meshchatx/src/backend/telephone_manager.py
+++ b/meshchatx/src/backend/telephone_manager.py
@@ -79,10 +79,6 @@ class TelephoneManager:
@property
def is_recording(self):
- # Check if voicemail manager or this manager is recording
- # This is a bit of a hack since we don't have a direct link to voicemail_manager here
- # but we can check if our own recording is active if we had it.
- # For now, we'll just return False and let meshchat.py handle the combined status.
return False
def init_telephone(self):
@@ -138,8 +134,6 @@ class TelephoneManager:
def on_telephone_ringing(self, caller_identity: RNS.Identity):
if self.initiation_status:
- # This is an outgoing call where the remote side is now ringing.
- # We update the initiation status to "Ringing..." for the UI.
self._update_initiation_status("Ringing...")
return

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 87a33d9d..05ec0c14 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1094,8 +1094,14 @@ export default {
// show result
const status = this.propagationNodeStatus?.state;
const messagesReceived = this.propagationNodeStatus?.messages_received ?? 0;
+ const messagesStored = this.propagationNodeStatus?.messages_stored ?? 0;
+ const deliveryConfirmations =
+ this.propagationNodeStatus?.delivery_confirmations ?? 0;
+ const messagesHidden = this.propagationNodeStatus?.messages_hidden ?? 0;
if (status === "complete" || status === "idle") {
- ToastUtils.success(this.$t("app.sync_complete", { count: messagesReceived }));
+ const base = this.$t("app.sync_complete", { count: messagesReceived });
+ const details = `${messagesStored} stored, ${deliveryConfirmations} confirmations, ${messagesHidden} hidden`;
+ ToastUtils.success(`${base} (${details})`);
} else {
ToastUtils.error(this.$t("app.sync_error", { status: status }));
}

diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index 26128145..8c108fde 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -3223,7 +3223,6 @@ export default {
}
},
async playVoicemail(voicemail) {
- // This is now handled by AudioWaveformPlayer, but we keep it for backward compatibility or direct calls
if (this.playingVoicemailId === voicemail.id) {
if (this.audioPlayer) {
this.audioPlayer.pause();

diff --git a/meshchatx/src/frontend/js/LinkUtils.js b/meshchatx/src/frontend/js/LinkUtils.js
index 4d939e8a..b9a1faa0 100644
--- a/meshchatx/src/frontend/js/LinkUtils.js
+++ b/meshchatx/src/frontend/js/LinkUtils.js
@@ -8,8 +8,7 @@ export default class LinkUtils {
// Hash is 32 hex chars. Path is optional (NomadNet only).
const hashPattern = "[a-fA-F0-9]{32}";
- // This matches optional prefixes (nomadnet://, nomadnet@, lxmf://, lxmf@)
- // followed by a 32-char hex hash, and an optional path starting with :
+ // Optional prefix (nomadnet://, nomadnet@, lxmf://, lxmf@), then hash, optional path.
const reticulumRegex = new RegExp(
`(nomadnet://|nomadnet@|lxmf://|lxmf@)?(${hashPattern})(?::(/[\\w\\d./?%&=-]*))?`,
"g"

diff --git a/meshchatx/src/frontend/js/MarkdownRenderer.js b/meshchatx/src/frontend/js/MarkdownRenderer.js
index da385431..0b37e573 100644
--- a/meshchatx/src/frontend/js/MarkdownRenderer.js
+++ b/meshchatx/src/frontend/js/MarkdownRenderer.js
@@ -7,11 +7,13 @@ export default class MarkdownRenderer {
* Ported and simplified from meshchatx/src/backend/markdown_renderer.py
*/
static render(text) {
- if (!text) {
+ if (text == null) {
return "";
}
+ if (typeof text !== "string") {
+ text = String(text);
+ }
- // Escape HTML entities first to prevent XSS
text = Utils.escapeHtml(text);
// Fenced code blocks - process these FIRST and replace with placeholders
@@ -81,9 +83,12 @@ export default class MarkdownRenderer {
* Strips markdown from text for previews.
*/
static strip(text) {
- if (!text) {
+ if (text == null) {
return "";
}
+ if (typeof text !== "string") {
+ text = String(text);
+ }
// Strip fenced code blocks
text = text.replace(/```(\w+)?\n([\s\S]*?)\n```/g, "[Code Block]");

diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js
index 169724e6..21b45ae5 100644
--- a/meshchatx/src/frontend/js/MicronParser.js
+++ b/meshchatx/src/frontend/js/MicronParser.js
@@ -115,9 +115,10 @@ class MicronParser {
}
convertMicronToHtml(markup) {
+ if (markup == null) return "";
+ if (typeof markup !== "string") markup = String(markup);
let html = "";
- // parse header tags for page-level color defaults
const headerColors = this.parseHeaderTags(markup);
const plainStyle = this.SELECTED_STYLES?.plain || { fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG };

diff --git a/meshchatx/src/frontend/js/Utils.js b/meshchatx/src/frontend/js/Utils.js
index ec905c0b..6c5823c1 100644
--- a/meshchatx/src/frontend/js/Utils.js
+++ b/meshchatx/src/frontend/js/Utils.js
@@ -197,7 +197,8 @@ class Utils {
}
static escapeHtml(text) {
- if (!text) return "";
+ if (text == null) return "";
+ text = String(text);
const map = {
"&": "&amp;",
"<": "&lt;",


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────